CDKTFで Google CloudのAPI GWにAPIキー認証&IP制限付きのAPIを構築してみた
CX事業本部@大阪の岩田です。
最近Google CloudのAPI Gateway & Cloud Functionsを使ってAPIを構築しているのですが、APIの認証方式としてAPIキーに加えてIP制限をかけたいという要件があったので、CDKTFで構築してみました。
環境
今回使用した各種ライブラリ等のバージョンは以下の通りです
- @cdktf: 0.20.2
- @cdktf-cli: 0.20.2
- @cdktf/provider-google: 13.2.0
- @cdktf/provider-google-beta: 13.2.0
- @cdktf/provider-random: 11.0.0
- @cdktf/provider-archive: 10.0.1
ソースコード
今回は必要なGoogle Cloudの各種APIが事前に有効化されている前提で作っています。本来は以下のブログの用にAPIの有効化も含めて実装した方が良いですが、今回は割愛します。
コードの全体は以下のようになりました。
import path = require("path"); import { Construct } from "constructs"; import { App, DataTerraformRemoteStateLocal, Fn, TerraformHclModule, TerraformOutput, TerraformStack, TerraformVariable } from "cdktf"; import { GoogleProvider } from "@cdktf/provider-google/lib/provider"; import { GoogleApiGatewayApi } from "@cdktf/provider-google-beta/lib/google-api-gateway-api"; import { GoogleBetaProvider } from "@cdktf/provider-google-beta/lib/provider"; import { GoogleApiGatewayApiConfigA } from "@cdktf/provider-google-beta/lib/google-api-gateway-api-config"; import { GoogleApiGatewayGateway } from "@cdktf/provider-google-beta/lib/google-api-gateway-gateway"; import { ApikeysKey } from "@cdktf/provider-google/lib/apikeys-key"; import { Cloudfunctions2Function } from "@cdktf/provider-google/lib/cloudfunctions2-function"; import { StorageBucket } from "@cdktf/provider-google/lib/storage-bucket"; import { StorageBucketObject } from "@cdktf/provider-google/lib/storage-bucket-object"; import { DataArchiveFile } from "@cdktf/provider-archive/lib/data-archive-file"; import { ArchiveProvider } from "@cdktf/provider-archive/lib/provider"; import { RandomProvider } from "@cdktf/provider-random/lib/provider"; import { StringResource } from "@cdktf/provider-random/lib/string-resource"; const region = 'asia-northeast1' const apiGwStackName = 'apiGw' class ApiGWStack extends TerraformStack { constructor(scope: Construct, id: string) { super(scope, id); const projectId = new TerraformVariable(this, "projectId", { type: "string", description: "Google Cloud ProjectId", }).value new GoogleProvider(this, 'GoogleProvider', { project: projectId, userProjectOverride: true, region }); const googleBetaProvider = new GoogleBetaProvider(this, 'GoogleBetaProvider', { project: projectId, userProjectOverride: true, region, }); new ArchiveProvider(this, 'archive-provider'); const api = new GoogleApiGatewayApi(this, 'api', { project: projectId, apiId: 'api', provider: googleBetaProvider, }) const bucket = new StorageBucket(this, 'bucket', { location: region, name: `${projectId}-function-deployment-bucket` }) const helloFuncZip = new DataArchiveFile(this, 'helloFuncArchive', { type: 'zip', sourceDir: path.resolve(__dirname, 'functions', 'hello'), outputPath: path.resolve(__dirname, '..', 'cdktf.out', 'functions', 'out', 'hello.zip'), }); const helloFuncZipObj = new StorageBucketObject(this, 'funcDeployment', { bucket: bucket.name, name: 'hello-func.zip', source: helloFuncZip.outputPath }) const helloFunc = new Cloudfunctions2Function(this, 'helloFunc', { project: projectId, location: region, name: 'helloFunc', buildConfig: { runtime: 'nodejs18', source: { storageSource: { bucket: bucket.name, object: helloFuncZipObj.name } }, entryPoint: 'helloHttp' } }) const apiDefFile = Fn.templatefile(path.resolve(__dirname, './swagger.yaml'), { region, projectId, helloFuncName: helloFunc.name }) const apiConfig = new GoogleApiGatewayApiConfigA(this, 'apiConfig', { project: projectId, provider: googleBetaProvider, api: api.apiId, apiConfigIdPrefix: 'api-config', openapiDocuments: [ { document: { contents: Fn.base64encode(apiDefFile), path: './swagger.yaml', } } ], lifecycle: { createBeforeDestroy: true }, dependsOn: [helloFunc] }) new GoogleApiGatewayGateway(this, 'apiGw', { provider: googleBetaProvider, apiConfig: apiConfig.id , gatewayId: 'api-gw', }) new TerraformOutput(this, "apiManagedService", { value: api.managedService }); } } class ApiKeyStack extends TerraformStack { constructor(scope: Construct, id: string) { super(scope, id); const projectId = new TerraformVariable(this, "projectId", { type: "string", description: "Google Cloud ProjectId", }).value new RandomProvider(this, 'RandomProvider') new GoogleProvider(this, 'GoogleProvider', { project: projectId, userProjectOverride: true, region }); new GoogleBetaProvider(this, 'GoogleBetaProvider', { project: projectId, userProjectOverride: true, region, }); const apiGwState = new DataTerraformRemoteStateLocal(this, 'apiGwStackState', { path: path.resolve(__dirname, `terraform.${apiGwStackName}.tfstate`) }) const apiManagedService = apiGwState.getString('apiManagedService') new TerraformHclModule(this, 'enable-apis', { source: 'terraform-google-modules/project-factory/google//modules/project_services', variables: { project_id: projectId, enable_apis: true, activate_apis: [ apiManagedService ] }, }); const apiKeySuffix = new StringResource(this, 'apiKeySuffix', { length: 8, special: false, upper: false, }).result // https://github.com/hashicorp/terraform-provider-google/issues/11726 new ApikeysKey(this,'apiKey', { project: projectId, name: `hello-api-${apiKeySuffix}`, displayName: `hello-api API Key ${apiKeySuffix}`, restrictions: { serverKeyRestrictions: { allowedIps: ['<アクセスを許可するCIDRブロック>'] }, apiTargets: [{ service: apiManagedService }] } }) } } const app = new App(); const apiGwStack = new ApiGWStack(app, apiGwStackName); const apiKeyStack = new ApiKeyStack(app, "apiKey"); apiKeyStack.addDependency(apiGwStack) app.synth();
上記CDKTFの同一階層にswagger.yaml
というファイル名で以下のAPI定義を置いています
swagger: '2.0' info: title: sample-api description: Sample API on API Gateway with a Google Cloud Functions backend version: 1.0.0 schemes: - https produces: - application/json paths: /hello: get: summary: Greet a user operationId: hello x-google-backend: address: https://${region}-${projectId}.cloudfunctions.net/${helloFuncName} responses: '200': description: A successful response schema: type: string security: - apiKey: [] securityDefinitions: apiKey: type: apiKey name: x-api-key in: header
ポイントとしてapiKeyによる認証を定義していますが、リクエストヘッダーを利用して認証する場合、ヘッダー名はx-api-key
である必要があります。このファイルは純粋なYAMLファイルではなく、Terraformのtemplatefile関数を利用してプロジェクトID等の値を動的に埋め込めるようにしています。
OpenAPI ドキュメントのセキュリティ定義オブジェクトで API キーを指定する場合、Endpoints では次のいずれかのスキームが必要です。
name は key、in は query です。 name は api_key、in は query です。 name は x-api-key、in は header です。
OpenAPI 機能の制限 | OpenAPI を使用した Cloud Endpoints | Google Cloud
デプロイするCloud Functionsのコードはfunctions/hello
というディレクトリ以下に以下の内容で作成しています。
const functions = require('@google-cloud/functions-framework'); functions.http('helloHttp', (req, res) => { res.send(`Hello ${req.query.name || req.body.name || 'World'}!`); });
{ "name": "hello", "version": "1.0.0", "main": "index.js", "dependencies": { "@google-cloud/functions-framework": "^3.0.0" } }
ポイントの解説
以後はコードのポイントとなる部分について解説していきます。まず今回の構成はStackを2つに分割し、スタック間に依存関係を設定しています。
const app = new App(); const apiGwStack = new ApiGWStack(app, apiGwStackName); const apiKeyStack = new ApiKeyStack(app, "apiKey"); apiKeyStack.addDependency(apiGwStack) app.synth();
全てを1つのスタックで処理してしまうと、Project API ActivationモジュールでAPIを有効化する際にThe "for_each" set includes values derived from resource attributes that cannot be determined until apply...
というエラーが発生するためです。このエラーを回避するため、スタックを分割することでnew TerraformHclModule(this, 'enable-apis', {...
でAPIを有効化する際、対象APIのサービス名が確実に解決されるよう調整しています。スタック間でのパラメータの受け渡しについてはDataTerraformRemoteStateLocal
を利用していますが、実際に業務で利用する際はstateファイルの保存先としてGCSバケットを利用するのが良いでしょう。
APIの構成をデプロイする箇所は以下のように記述しています。
const apiConfig = new GoogleApiGatewayApiConfigA(this, 'apiConfig', { project: projectId, provider: googleBetaProvider, api: api.apiId, apiConfigIdPrefix: 'api-config', openapiDocuments: [ { document: { contents: Fn.base64encode(apiDefFile), path: './swagger.yaml', } } ], lifecycle: { createBeforeDestroy: true }, dependsOn: [helloFunc] })
一度API Gatewayをデプロイした後に構成を変更して再デプロイできるようにapiConfigId
ではなくapiConfigIdPrefix
を指定して名前が重複しないようにしています。またlifecycle
でcreateBeforeDestroy: true
を指定することで、API Gatewayが利用中のAPI構成を削除しようとしてエラーになることを回避しています。
APIキーを作成する部分では、サフィックスとしてランダムな文字列を付与しています。
const apiKeySuffix = new StringResource(this, 'apiKeySuffix', { length: 8, special: false, upper: false, }).result // https://github.com/hashicorp/terraform-provider-google/issues/11726 new ApikeysKey(this,'apiKey', { project: projectId, name: `hello-api-${apiKeySuffix}`, displayName: `hello-api API Key ${apiKeySuffix}`, restrictions: { serverKeyRestrictions: { allowedIps: ['<アクセスを許可するCIDRブロック>'] }, apiTargets: [{ service: apiManagedService }] } })
これはAPIキーが完全に削除されるまでは30日を要するという仕様によってAPIキーの再作成時にエラーが発生することを抑止しています。APIキーの名前を固定化してしまうと、開発段階のトライ&エラーなどでAPIキーを削除→再作成しようとした際に、存在するキーを作成しようとしてエラーになる可能性があります。
API キーを誤って削除した場合、キーを削除してから 30 日以内であれば、そのキーの削除を取り消す(復元する)ことができます。30 日を経過すると、API キーの削除を取り消すことはできません。
API キーを使用して認証する | Google Cloud
今回アクセスを許可するCIDRブロックはオンコードで記述していますが、業務利用する際は別ファイルにCIDRの定義を切り出したりコマンドラインオプション等でvariableを指定する方が良いでしょう。
動作確認
では実際にデプロイして動作確認してみましょう。2つのスタックをまとめてデプロイします。
TF_VAR_projectId=<プロジェクトID> npm run cdktf -- deploy apiGw apiKey
デプロイ完了後API GWのエンドポイントにcurlでリクエストしてみます。まずはAPIキー無しで
curl https://api-gw-35mul75l.an.gateway.dev/hello {"code":401,"message":"UNAUTHENTICATED: Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API."}
401エラーになりました
次にAPIキー有りかつAPIキーの利用を許可されていないGIPからリクエストしてみます
curl https://api-gw-35mul75l.an.gateway.dev/hello -H "x-api-key: AIzaSyATop7iUnk2Vw7lnkK50kyEIUMSqDj9Yy0" {"message":"PERMISSION_DENIED: IP address blocked.","code":403}
IP制限によりアクセスがブロックされました。
最後にAPIキー有りかつAPIキーの利用を許可されたGIPからリクエストしてみます
curl https://api-gw-35mul75l.an.gateway.dev/hello -H "x-api-key: AIzaSyATop7iUnk2Vw7lnkK50kyEIUMSqDj9Yy0" Hello World!
無事に正常系のレスポンスが得られました!
まとめ
筆者は普段AWSのAPI GWをよく触っているのですが、Google CloudのAPI GatewayはAWSと考え方が違う部分があり、最初はその部分に躓きました。
- APIキーを利用するためにAPIライブラリから対象のAPIを有効化する必要がある
- IP制限をかける対象はAPI GatewayではなくAPIキーになる
AWSとの違いとして上記のポイントが理解できているとスムーズに構築できそうです。